Comparing runtypes and zod

Work in progress

This post is a work in progress and I'm publishing it early because I haven't posted this month and I want to develop a habit of writing and this sort of commits me to finishing it. Even though the post is incomplete working on it has already deepened my understanding of these libraries. One unexpected thing that I learned is that I've been overusing runtypes by using it to derive static types when I'm not ever using the runtype for runtime validation. Just use static types in that case!

Libraries like runtypes and zod are nice for validating data at runtime and you can generate static types from them as well which gives them a lot of utility. The primary codebase I work in uses Runtypes to define most of our shared types and we use yup in a few places, primarily for form validation with Formik. Meanwhile I've been tinkering with zod in my personal projects and I've been enjoying it. I'd like to consolidate my work codebase to use a single library and I've been comparing the three libraries to see which one suits our needs best.

A couple months ago I was building a feature that required a lot of validation and I thought I'd use yup because the design is a little more like zod and I just kind of like it better. However, yup was unable to accomplish one particular thing that I needed and runtypes was, so I ended up using runtypes for that feature. Unfortunately I didn't do a good job documenting the issue I ran into but if I can recall it later I'll write a post about it. So for this post I'm going to focus on zod and runtypes.

Generics

I wanted to recreate some existing static types in runtypes/zod and generate static types equivalent to the originals. The solutions were similar in both libraries.


	// runtypes
	import { Record, Number, type Runtype, String, type Static } from "runtypes";

	const configSchema = Record({
		port: Number,
	});

	type Config = Static<typeof configSchema>;

	const applicationSchema = <C extends Runtype>(configSchema: C) =>
	Record({
		name: String,
		config: configSchema,
	});

	type Application<C extends Runtype> = Static<
		ReturnType<typeof applicationSchema<C>>
	>;

	const myApp: Application<typeof configSchema> = {
		config: {
			port: 3000,
		},
		name: "my-app",
	};

	export { myApp };
  

	// zod
	import { z } from "zod";

	const configSchema = z.object({
	port: z.number(),
	});

	type Config = z.infer<typeof configSchema>;

	const applicationSchema = <C extends z.ZodTypeAny>(configSchema: C) =>
	z.object({
		name: z.string(),
		config: configSchema,
	});

	type Application<C extends z.ZodTypeAny> = z.infer<
		ReturnType<typeof applicationSchema<C>>
	>;

	const myApp: Application<typeof configSchema> = {
		config: {
			port: 3000,
		},
		name: "my-app",
	};

	export { myApp };
  

Validation errors

In runtypes, running foo.check(bar) where foo contains a complex unioned property and validation for one of the runtypes in the union fails, the validation message is huge and hard to make sense of. Validating each unioned runtype individually yields a useful message, but may not be practical from a code maintenance perspective.